Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 96243d4666e6dfdd4232818502acc0f1f86ffa01


Parents : dee8de8
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-06T22:18:15-05:00

feat(plugin): implement plugin management API and fix configuration validation

Changes
Diff

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 4997d70a..f351189a 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -148,6 +148,7 @@ from meshchatx.src.backend.nomadnet_utils import (
convert_nomadnet_string_data_to_map,
)
from meshchatx.src.backend.page_node_manager import PageNodeManager
+from meshchatx.src.backend.plugin_manager import PluginManager
from meshchatx.src.backend.persistent_log_handler import PersistentLogHandler
from meshchatx.src.backend.app_security_settings import (
get_web_ui_ip_allowlist,
@@ -160,6 +161,11 @@ from meshchatx.src.backend.csrf import (
validate_csrf_header,
)
from meshchatx.src.backend.ip_allowlist import client_ip_allowed
+from meshchatx.src.backend.reticulum_config_guard import (
+ repair_unparseable_reticulum_config,
+ reticulum_config_has_required_sections,
+)
+from meshchatx.src.backend.websocket_config_guard import sanitize_websocket_config_update
from meshchatx.src.backend.landlock_sandbox import (
apply_landlock_sandbox,
landlock_auto_enabled,
@@ -432,6 +438,7 @@ class ReticulumMeshChat:
self.identity_manager = IdentityManager(self.storage_dir, identity_file_path)
self.page_node_manager = PageNodeManager(self.storage_dir)
+ self.plugin_manager = PluginManager(self.storage_dir, app=self)
# Multi-identity support
self.contexts: dict[str, IdentityContext] = {}
@@ -872,21 +879,17 @@ class ReticulumMeshChat:
self._repair_reticulum_instance_name_corruption()
self._reticulum_instance_name_startup_repair_done = True
config_path = os.path.join(config_dir, "config")
- needs_default = True
- if os.path.isfile(config_path):
- try:
- with open(config_path) as f:
- content = f.read()
- if "[reticulum]" in content and "[interfaces]" in content:
- needs_default = False
- except OSError:
- pass
+ needs_default = not reticulum_config_has_required_sections(config_path)
+ if not needs_default:
+ repair_unparseable_reticulum_config(
+ config_path,
+ write_default=self._write_rns_reticulum_default_config_file,
+ )
+ needs_default = not reticulum_config_has_required_sections(config_path)
if needs_default:
if not os.path.isdir(config_dir):
os.makedirs(config_dir, exist_ok=True)
self._write_rns_reticulum_default_config_file(config_path)
- # Scrub stale default_bootstrap_only from Reticulum config so it never
- # affects discovered/auto-connected interfaces.
try:
from RNS.vendor.configobj import ConfigObj
@@ -894,8 +897,12 @@ class ReticulumMeshChat:
if "default_bootstrap_only" in cfg.get("reticulum", {}):
cfg["reticulum"].pop("default_bootstrap_only", None)
cfg.write()
- except Exception:
- pass
+ except Exception as exc:
+ logger.warning(
+ "Failed to scrub default_bootstrap_only from %s: %s",
+ config_path,
+ exc,
+ )
from meshchatx.src.backend.rnode_support import (
guard_invalid_rnode_txpower_in_config,
guard_rnode_interfaces_on_android,
@@ -936,6 +943,8 @@ class ReticulumMeshChat:
_restore_rns_console_logging_after_reticulum_init(self)
self.page_node_manager.load_nodes()
self.page_node_manager.start_all()
+ self.plugin_manager.set_app(self)
+ self.plugin_manager.install_bundled_examples()
# Create new context
context = IdentityContext(identity, self)
@@ -6183,7 +6192,10 @@ class ReticulumMeshChat:
print(f"ws connection error {websocket_response.exception()}")
# websocket closed
- self.websocket_clients.remove(websocket_response)
+ try:
+ self.websocket_clients.remove(websocket_response)
+ except ValueError:
+ pass
return websocket_response
@@ -10973,6 +10985,99 @@ class ReticulumMeshChat:
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
+ # --- Plugin API ---
+
+ @routes.get("/api/v1/plugins")
+ async def plugins_list(request):
+ return web.json_response({"plugins": self.plugin_manager.list_plugins()})
+
+ @routes.post("/api/v1/plugins/install")
+ async def plugins_install(request):
+ try:
+ if request.content_type and "multipart" in request.content_type:
+ reader = await request.multipart()
+ field = await reader.next()
+ if field is None:
+ return web.json_response({"message": "No plugin archive provided"}, status=400)
+ payload = await field.read()
+ plugin = await asyncio.to_thread(
+ self.plugin_manager.install_from_zip_bytes, payload
+ )
+ return web.json_response(plugin)
+ data = await request.read()
+ if not data:
+ return web.json_response({"message": "No plugin archive provided"}, status=400)
+ plugin = await asyncio.to_thread(self.plugin_manager.install_from_zip_bytes, data)
+ return web.json_response(plugin)
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=400)
+
+ @routes.post("/api/v1/plugins/{plugin_id}/enable")
+ async def plugins_enable(request):
+ plugin_id = request.match_info["plugin_id"]
+ try:
+ plugin = await asyncio.to_thread(self.plugin_manager.enable, plugin_id)
+ return web.json_response(plugin)
+ except KeyError:
+ return web.json_response({"message": "Plugin not found"}, status=404)
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=400)
+
+ @routes.post("/api/v1/plugins/{plugin_id}/disable")
+ async def plugins_disable(request):
+ plugin_id = request.match_info["plugin_id"]
+ try:
+ plugin = await asyncio.to_thread(self.plugin_manager.disable, plugin_id)
+ return web.json_response(plugin)
+ except KeyError:
+ return web.json_response({"message": "Plugin not found"}, status=404)
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=400)
+
+ @routes.delete("/api/v1/plugins/{plugin_id}")
+ async def plugins_remove(request):
+ plugin_id = request.match_info["plugin_id"]
+ try:
+ await asyncio.to_thread(self.plugin_manager.remove, plugin_id)
+ return web.json_response({"message": "Plugin removed"})
+ except KeyError:
+ return web.json_response({"message": "Plugin not found"}, status=404)
+
+ @routes.post("/api/v1/plugins/{plugin_id}/invoke")
+ async def plugins_invoke(request):
+ plugin_id = request.match_info["plugin_id"]
+ try:
+ data = await request.json()
+ except Exception:
+ data = {}
+ method = data.get("method")
+ args = data.get("args") or {}
+ if not method:
+ return web.json_response({"message": "method is required"}, status=400)
+ try:
+ result = await asyncio.to_thread(self.plugin_manager.invoke, plugin_id, method, args)
+ return web.json_response({"result": result})
+ except KeyError:
+ return web.json_response({"message": "Plugin not found"}, status=404)
+ except PermissionError as e:
+ return web.json_response({"message": str(e)}, status=403)
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=400)
+
+ @routes.get("/api/v1/plugins/{plugin_id}/asset/{asset_path:.*}")
+ async def plugins_asset(request):
+ plugin_id = request.match_info["plugin_id"]
+ asset_path = request.match_info["asset_path"]
+ try:
+ path = self.plugin_manager.asset_path(plugin_id, asset_path)
+ except KeyError:
+ return web.json_response({"message": "Plugin not found"}, status=404)
+ except FileNotFoundError:
+ return web.json_response({"message": "Asset not found"}, status=404)
+ except ValueError as e:
+ return web.json_response({"message": str(e)}, status=400)
+ return web.FileResponse(path)
+
# --- Page Node API ---
@routes.get("/api/v1/page-nodes")
@@ -15535,8 +15640,7 @@ class ReticulumMeshChat:
# handle updating config
elif _type == "config.set":
- # get config from websocket
- config = data["config"]
+ config = sanitize_websocket_config_update(data.get("config"))
try:
await self.update_config(config)
@@ -17617,6 +17721,13 @@ class ReticulumMeshChat:
"""Handle inbound LXMF delivery from Reticulum (synchronous callback)."""
ctx = context or self.current_context
if not ctx or not ctx.running or not ctx.database:
+ logger.warning(
+ "Dropping inbound LXMF delivery: context not ready "
+ "(ctx=%s running=%s database=%s)",
+ ctx is not None,
+ getattr(ctx, "running", None) if ctx else None,
+ ctx.database is not None if ctx else None,
+ )
return
try:
@@ -18297,7 +18408,17 @@ class ReticulumMeshChat:
else:
lxmf_message_dict["peer_hash"] = lxmf_message_dict["source_hash"]
- ctx.database.messages.upsert_lxmf_message(lxmf_message_dict)
+ try:
+ ctx.database.messages.upsert_lxmf_message(lxmf_message_dict)
+ except Exception:
+ message_hash = getattr(lxmf_message, "hash", None)
+ hash_label = message_hash.hex() if message_hash is not None else "unknown"
+ logger.exception(
+ "Failed to persist inbound LXMF message %s from %s",
+ hash_label,
+ lxmf_message_dict.get("source_hash", "unknown"),
+ )
+ raise
def _lxmf_path_wait_seconds(self):
return reticulum_pathfinding.lxmf_path_wait_cap_seconds()

diff --git a/meshchatx/src/backend/reticulum_config_guard.py b/meshchatx/src/backend/reticulum_config_guard.py
new file mode 100644
index 00000000..20695f09
--- /dev/null
+++ b/meshchatx/src/backend/reticulum_config_guard.py
@@ -0,0 +1,81 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Reticulum config file validation helpers for startup repair."""
+
+from __future__ import annotations
+
+import logging
+import os
+import shutil
+import time
+
+logger = logging.getLogger(__name__)
+
+
+def reticulum_config_has_required_sections(config_path: str) -> bool:
+ if not os.path.isfile(config_path):
+ return False
+ try:
+ with open(config_path, encoding="utf-8") as handle:
+ content = handle.read()
+ except OSError:
+ return False
+ return "[reticulum]" in content and "[interfaces]" in content
+
+
+def reticulum_config_is_parseable(config_path: str) -> bool:
+ if not os.path.isfile(config_path):
+ return False
+ try:
+ from RNS.vendor.configobj import ConfigObj
+
+ cfg = ConfigObj(config_path)
+ except Exception:
+ return False
+ return isinstance(cfg.get("reticulum"), dict) and isinstance(
+ cfg.get("interfaces"),
+ dict,
+ )
+
+
+def backup_reticulum_config_file(config_path: str) -> str | None:
+ if not os.path.isfile(config_path):
+ return None
+ stamp = time.strftime("%Y%m%d-%H%M%S")
+ backup_path = f"{config_path}.corrupt.{stamp}"
+ try:
+ shutil.copy2(config_path, backup_path)
+ except OSError as exc:
+ logger.warning("Failed to back up corrupt Reticulum config %s: %s", config_path, exc)
+ return None
+ return backup_path
+
+
+def repair_unparseable_reticulum_config(config_path: str, *, write_default) -> bool:
+ """Back up and rewrite *config_path* when ConfigObj cannot parse it.
+
+ *write_default* must be a callable accepting the config path and writing
+ stock RNS defaults (``ReticulumMeshChat._write_rns_reticulum_default_config_file``).
+
+ Returns True when the file was replaced.
+ """
+ if not reticulum_config_has_required_sections(config_path):
+ return False
+ if reticulum_config_is_parseable(config_path):
+ return False
+
+ backup_path = backup_reticulum_config_file(config_path)
+ if backup_path:
+ logger.warning(
+ "Reticulum config at %s is unparseable; backed up to %s",
+ config_path,
+ backup_path,
+ )
+ else:
+ logger.warning(
+ "Reticulum config at %s is unparseable; rewriting without backup",
+ config_path,
+ )
+
+ write_default(config_path)
+ return True

diff --git a/meshchatx/src/backend/websocket_config_guard.py b/meshchatx/src/backend/websocket_config_guard.py
new file mode 100644
index 00000000..6d677129
--- /dev/null
+++ b/meshchatx/src/backend/websocket_config_guard.py
@@ -0,0 +1,39 @@
+# SPDX-License-Identifier: 0BSD
+
+"""WebSocket config update guards.
+
+Settings that change the HTTP security boundary must go through CSRF-protected
+HTTP endpoints, not the unauthenticated ``config.set`` WebSocket message.
+"""
+
+from __future__ import annotations
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+WEBSOCKET_CONFIG_DENYLIST = frozenset(
+ {
+ "auth_enabled",
+ "auth_password_hash",
+ },
+)
+
+
+def sanitize_websocket_config_update(config: object) -> dict:
+ """Return a copy of *config* with security-sensitive keys removed."""
+ if not isinstance(config, dict):
+ return {}
+
+ sanitized = dict(config)
+ removed = [key for key in WEBSOCKET_CONFIG_DENYLIST if key in sanitized]
+ for key in removed:
+ del sanitized[key]
+
+ if removed:
+ logger.warning(
+ "Ignored security-sensitive config keys over WebSocket: %s",
+ ", ".join(sorted(removed)),
+ )
+
+ return sanitized

diff --git a/tests/backend/test_reticulum_config_guard.py b/tests/backend/test_reticulum_config_guard.py
new file mode 100644
index 00000000..c05c937f
--- /dev/null
+++ b/tests/backend/test_reticulum_config_guard.py
@@ -0,0 +1,70 @@
+# SPDX-License-Identifier: 0BSD
+
+from meshchatx.src.backend import reticulum_config_guard as guard
+
+
+def test_reticulum_config_has_required_sections(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text("[reticulum]\n", encoding="utf-8")
+ assert guard.reticulum_config_has_required_sections(str(config_path)) is False
+
+ config_path.write_text("[reticulum]\n[interfaces]\n", encoding="utf-8")
+ assert guard.reticulum_config_has_required_sections(str(config_path)) is True
+
+
+def test_reticulum_config_is_parseable_rejects_broken_configobj(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ "[reticulum]\n[[broken\n[interfaces]\n",
+ encoding="utf-8",
+ )
+ assert guard.reticulum_config_has_required_sections(str(config_path)) is True
+ assert guard.reticulum_config_is_parseable(str(config_path)) is False
+
+
+def test_backup_reticulum_config_file_creates_copy(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text("broken", encoding="utf-8")
+
+ backup_path = guard.backup_reticulum_config_file(str(config_path))
+ assert backup_path is not None
+ assert ".corrupt." in backup_path
+ assert open(backup_path, encoding="utf-8").read() == "broken"
+
+
+def test_repair_unparseable_reticulum_config_rewrites_file(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ "[reticulum]\n[[broken\n[interfaces]\n",
+ encoding="utf-8",
+ )
+ written = []
+
+ def write_default(path: str) -> None:
+ written.append(path)
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write("[reticulum]\n[interfaces]\nfixed = true\n")
+
+ assert guard.repair_unparseable_reticulum_config(
+ str(config_path),
+ write_default=write_default,
+ ) is True
+ assert written == [str(config_path)]
+ assert "fixed = true" in config_path.read_text(encoding="utf-8")
+
+
+def test_repair_unparseable_reticulum_config_skips_valid_file(tmp_path):
+ config_path = tmp_path / "config"
+ config_path.write_text(
+ """[reticulum]
+share_instance = False
+
+[interfaces]
+""",
+ encoding="utf-8",
+ )
+
+ assert guard.repair_unparseable_reticulum_config(
+ str(config_path),
+ write_default=lambda _path: (_ for _ in ()).throw(AssertionError("should not write")),
+ ) is False

diff --git a/tests/backend/test_rns_config_management.py b/tests/backend/test_rns_config_management.py
index 46393447..0d866e69 100644
--- a/tests/backend/test_rns_config_management.py
+++ b/tests/backend/test_rns_config_management.py
@@ -89,6 +89,33 @@ def test_rns_config_repair_if_invalid(mock_rns, temp_dir):
assert "[interfaces]" in content
+def test_rns_config_repair_if_sections_present_but_unparseable(mock_rns, temp_dir):
+ """Config files that look valid but fail ConfigObj parsing must be rewritten."""
+ config_dir = os.path.join(temp_dir, ".reticulum")
+ os.makedirs(config_dir, exist_ok=True)
+ config_file = os.path.join(config_dir, "config")
+ with open(config_file, "w") as f:
+ f.write("[reticulum]\n[[broken\n[interfaces]\n")
+
+ with (
+ patch("meshchatx.meshchat.IdentityContext"),
+ patch("meshchatx.meshchat.WebAudioBridge"),
+ patch("meshchatx.meshchat.memory_log_handler"),
+ ):
+ ReticulumMeshChat(
+ identity=mock_rns["id_instance"],
+ storage_dir=temp_dir,
+ reticulum_config_dir=config_dir,
+ )
+
+ with open(config_file) as f:
+ content = f.read()
+ assert "[reticulum]" in content
+ assert "[interfaces]" in content
+ assert "enable_transport = False" in content
+ assert "[[broken" not in content
+
+
def test_rns_config_file_path_is_normalized_to_directory(mock_rns, temp_dir):
"""A config file path should be normalized to its parent directory."""
config_dir = os.path.join(temp_dir, ".reticulum")

diff --git a/tests/backend/test_websocket_config_guard.py b/tests/backend/test_websocket_config_guard.py
new file mode 100644
index 00000000..ee9d299b
--- /dev/null
+++ b/tests/backend/test_websocket_config_guard.py
@@ -0,0 +1,22 @@
+# SPDX-License-Identifier: 0BSD
+
+import pytest
+
+from meshchatx.src.backend.websocket_config_guard import sanitize_websocket_config_update
+
+
+def test_sanitize_websocket_config_update_strips_auth_keys():
+ payload = {
+ "display_name": "Peer",
+ "auth_enabled": False,
+ "auth_password_hash": "deadbeef",
+ "theme": "dark",
+ }
+
+ sanitized = sanitize_websocket_config_update(payload)
+ assert sanitized == {"display_name": "Peer", "theme": "dark"}
+
+
+@pytest.mark.parametrize("payload", [None, [], "bad", 42])
+def test_sanitize_websocket_config_update_rejects_non_dict(payload):
+ assert sanitize_websocket_config_update(payload) == {}

diff --git a/tests/backend/test_websocket_config_security.py b/tests/backend/test_websocket_config_security.py
new file mode 100644
index 00000000..8477d867
--- /dev/null
+++ b/tests/backend/test_websocket_config_security.py
@@ -0,0 +1,26 @@
+# SPDX-License-Identifier: 0BSD
+
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_websocket_config_set_ignores_auth_enabled(mock_app):
+ mock_app.config.auth_enabled.set(True)
+ mock_app.config.auth_password_hash.set("existing-hash")
+
+ client = object()
+ await mock_app.on_websocket_data_received(
+ client,
+ {
+ "type": "config.set",
+ "config": {
+ "display_name": "Updated Peer",
+ "auth_enabled": False,
+ "auth_password_hash": None,
+ },
+ },
+ )
+
+ assert mock_app.config.auth_enabled.get() is True
+ assert mock_app.config.auth_password_hash.get() == "existing-hash"
+ assert mock_app.config.display_name.get() == "Updated Peer"


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────